Published on

RAG检索增强生成(四)

Authors
  • avatar
    Name
    游戏人生
    Twitter

增强 RAG 能力

改写 LLM 提问

为了提高检索的质量,需要对用户的提问进行改写,让其成为一个独立的问题,包含检索的所有关键词。LLM app 遇到问题时,通常会尝试加入更多的 LLM 来解决问题。

首先定义 prompt,通过 system prompt 去给 llm 确定任务,根据聊天记录去把对话重新描述成一个独立的问题,并强调重述问题的目标:

  import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
  
  const rephraseChainPrompt = ChatPromptTemplate.fromMessages([
    [
      "system",
      "给定以下对话和一个后续问题,请将后续问题重述为一个独立的问题。请注意,重述的问题应该包含足够的信息,使得没有看过对话历史的人也能理解。",
    ],
    new MessagesPlaceholder("history"),
    ["human", "将以下问题重述为一个独立的问题:\n{question}"],
  ]);

据此构成一个 chain:

  import { ChatAlibabaTongyi } from "@langchain/community/chat_models/alibaba_tongyi";
  import { RunnableSequence } from "@langchain/core/runnables";
  import { StringOutputParser } from "@langchain/core/output_parsers";

  const rephraseChain = RunnableSequence.from([
    rephraseChainPrompt,
    new ChatAlibabaTongyi({
      model: "qwen-turbo",
      temperature: 0.2,
    }),
    new StringOutputParser(),
  ]);

测试效果:

  import { HumanMessage, AIMessage } from "@langchain/core/messages";

  const historyMessages = [new HumanMessage("你好,我是叮当猫"), new AIMessage("你好,叮当猫")];
  
  const question = "你觉得我的名字怎么样?";
  const standaloneQuestion = await rephraseChain.invoke({ history: historyMessages, question });

  console.log(standaloneQuestion);
  // 你觉得“叮当猫”这个名字如何?

可以看到,这里使用了 “我的名字” 这个代词,在 llm 的重述下,将这个替换成了 “叮当猫”。这个处理除了可以解决代词的问题,也能解决一些自然语言灵活性带来的问题,保证进行 retriver 时的问题是高质量的。

构建完整的 RAG chain

使用了 Faiss 作为本地的数据库,将 RAG 相关知识点串联起来,构建一个完整的 RAG chain,代码需要运行在 node 环境。

切割文本,并保存在本地的数据库:prepare.ts

import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import "dotenv/config";
import { FaissStore } from "@langchain/community/vectorstores/faiss";
import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
import path from "path";

const run = async () => {
  const baseDir = __dirname;

  const loader = new TextLoader(path.join(baseDir, "../../data/qiu.txt"));
  const docs = await loader.load();

  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,
    chunkOverlap: 100,
  });

  const splitDocs = await splitter.splitDocuments(docs);

  const embeddings = new AlibabaTongyiEmbeddings();
  const vectorStore = await FaissStore.fromDocuments(splitDocs, embeddings);

  await vectorStore.save(path.join(baseDir, "../../db/qiu"));
};

run();

核心代码 index.ts:

import { FaissStore } from "@langchain/community/vectorstores/faiss";
import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
import { ChatAlibabaTongyi } from "@langchain/community/chat_models/alibaba_tongyi";
import "dotenv/config";
import path from "path";
import { JSONChatHistory } from "../../JSONChatHistory/index";
import {
  ChatPromptTemplate,
  MessagesPlaceholder,
} from "@langchain/core/prompts";
import {
  RunnableSequence,
  RunnablePassthrough,
  RunnableWithMessageHistory,
  Runnable,
} from "@langchain/core/runnables";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { HumanMessage, AIMessage } from "@langchain/core/messages";
import { Document } from "@langchain/core/documents";

/**
 * 根据重写后的独立问题去读取数据库的中相关文档
 */
async function loadVectorStore() {
  const directory = path.join(__dirname, "../../db/qiu");
  const embeddings = new AlibabaTongyiEmbeddings();
  const vectorStore = await FaissStore.load(directory, embeddings);

  return vectorStore;
}

async function getRephraseChain() {
  const rephraseChainPrompt = ChatPromptTemplate.fromMessages([
    [
      "system",
      "给定以下对话和一个后续问题,请将后续问题重述为一个独立的问题。请注意,重述的问题应该包含足够的信息,使得没有看过对话历史的人也能理解。",
    ],
    new MessagesPlaceholder("history"),
    ["human", "将以下问题重述为一个独立的问题:\n{question}"],
  ]);

  const rephraseChain = RunnableSequence.from([
    rephraseChainPrompt,
    new ChatAlibabaTongyi({
      model: "qwen-turbo",
      temperature: 0.4,
    }),
    new StringOutputParser(),
  ]);

  return rephraseChain;
}

async function testRephraseChain() {
  const historyMessages = [
    new HumanMessage("你好,我是叮当猫"),
    new AIMessage("你好叮当猫"),
  ];
  const rephraseChain = await getRephraseChain();

  const question = "你觉得我的名字怎么样?";
  const standaloneQuestion = await rephraseChain.invoke({
    history: historyMessages,
    question,
  });

  console.log(standaloneQuestion);
}

export async function getRagChain(): Promise<Runnable> {
  const vectorStore = await loadVectorStore();
  const retriever = vectorStore.asRetriever(2);

  /**
   * 使用 retriever 获取相关文档,然后转换成纯字符串。
   * @param documents
   * @returns
   */
  const convertDocsToString = (documents: Document[]): string => {
    return documents.map((document) => document.pageContent).join("\n");
  };
  const contextRetrieverChain = RunnableSequence.from([
    (input) => input.standalone_question,
    retriever,
    convertDocsToString,
  ]);

  const SYSTEM_TEMPLATE = `
    你是一个熟读刘慈欣的《球状闪电》的终极原着党,精通根据作品原文详细解释和回答问题,你在回答时会引用作品原文。
    并且回答时仅根据原文,尽可能回答用户问题,如果原文中没有相关内容,你可以回答“原文中没有相关内容”,

    以下是原文中跟用户回答相关的内容:
    {context}
  `;

  /**
   * 包含历史记录信息的 prompt
   */
  const prompt = ChatPromptTemplate.fromMessages([
    ["system", SYSTEM_TEMPLATE],
    new MessagesPlaceholder("history"),
    ["human", "现在,你需要基于原文,回答以下问题:\n{standalone_question}`"],
  ]);

  const model = new ChatAlibabaTongyi({
    model: "qwen-turbo",
  });
  const rephraseChain = await getRephraseChain();

  /**
   * 改写提问 => 根据改写后的提问获取文档 => 生成回复 的 rag chain
   */
  const ragChain = RunnableSequence.from([
    RunnablePassthrough.assign({
      standalone_question: rephraseChain,
    }),
    RunnablePassthrough.assign({
      context: contextRetrieverChain,
    }),
    prompt,
    model,
    new StringOutputParser(),
  ]);

  const chatHistoryDir = path.join(__dirname, "../../chat_data");

  /**
   * 使用 RunnableWithMessageHistory 去管理 history,给 chain 增加聊天记录的功能
   * 传给 getMessageHistory 的函数,需要根据用户传入的 sessionId 去获取初始的 chat history
   */
  const ragChainWithHistory = new RunnableWithMessageHistory({
    runnable: ragChain,
    getMessageHistory: (sessionId) =>
      new JSONChatHistory({ sessionId, dir: chatHistoryDir }),
    historyMessagesKey: "history",
    inputMessagesKey: "question",
  });

  return ragChainWithHistory;
}

async function run() {
  const ragChain = await getRagChain();

  const res = await ragChain.invoke(
    {
        question: "什么是球状闪电?",
    },
    {
      configurable: { sessionId: "test-history" },
    }
  );

  console.log(res);
}

测试代码,首先执行 prepare.ts 文件,切割文本,保存到本地的 docstore.json 中:

  ts-node ./node/rag/prepare.ts 

执行 index.ts 文件,在 index.ts 文件中,先增加函数执行 run(),在执行文件:

  ts-node ./node/rag/index.ts 

输出内容如下:

  球状闪电是刘慈欣作品《球状闪电》中的关键概念,它是一种自然现象,表现为一种明亮的、球形或椭圆形的发光体,通常出现在雷暴天气中。书中提到球状闪电的行为和特性是基于真实历史记录的描述,它们具有量子性质,在失去观察者后会消失并重新生成,显示出超乎寻常的物理特性。在小说中,球状闪电的研究被用于开发一种高度先进的武器系统,但其基本原理和技术细节并未完全揭示给读者。

至此,就完成了一个非常完整的 rag chain,有自动的提问改写、数据检索、聊天记录等基本的功能。

部署成 API

将开发完成的 chain 部署成 API,方便用户调用。新建 server.ts 文件,代码如下:

import express from "express";
import { getRagChain } from ".";

const app = express();
const port = 3001;

app.use(express.json());

app.post("/", async (req, res) => {
  const ragChain = await getRagChain();
  const body = req.body;
  const result = await ragChain.stream(
    {
      question: body.question,
    },
    { configurable: { sessionId: body.session_id } }
  );

  res.set("Content-Type", "text/plain");
  for await (const chunk of result) {
    res.write(chunk);
  }
  res.end();
});

app.listen(port, () => {
  console.log(`Server is running on port ${port}`);
});

启动服务器:

  ts-node ./node/rag/server.ts 

新增 client.ts 文件,调用 API:

const port = 3001;

async function fetchStream() {
  const response = await fetch(`http://localhost:${port}`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
    },
    body: JSON.stringify({
      question: "什么是球状闪电",
      session_id: "test-server",
    }),
  });

  const reader = response?.body?.getReader();
  const decoder = new TextDecoder();

  while (true && reader) {
    const { done, value } = await reader.read();
    if (done) break;
    console.log(decoder.decode(value));
  }

  console.log("Stream has ended");
}

fetchStream();

执行 client.ts,输出内容如下:

闪电
是刘慈欣作品
《球状闪电》中的关键概念
,它是一种自然现象,表现为一种
明亮的、球形或椭圆形
的发光体,通常出现在雷暴
天气中。书中提到球状闪电
的行为和特性是基于真实历史记录
的描述,它们具有量子性质,在
失去观察者后会消失并重新
生成,显示出超乎寻常的物理
特性。在小说中,球状
闪电的研究被用于揭示微观世界的秘密
,尽管其军事应用相对较小。
Stream has ended

到此就完成了一个简单的 rag chain,通过调用 API,实现了问答系统的功能。